@surface/htmlx-element
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.

While @surface/htmlx-element provides a base class and an set of decorators to simplify htmlx templates usage.

Getting Started

A minimal component requires two things: Extend the HTMLXElement class and annotate the class with the element's decorator.

Simple hello world component

Typescript.

import HTMLXElement, { element } from "@surface/htmlx-element";

const template = "<span>Hello {host.name}!!!</span>";
const style    = "span { color: red; }";

@element("my-element", { template, style })
class MyElement extends HTMLXElement
{
    public name: string = "World";
}

document.body.appendChild(new MyElement());

Javascript (no decorators).

import HTMLXElement, { element } from "@surface/htmlx-element";

const template = "<span>Hello {host.name}!!!</span>";
const style    = "span { color: red; }";

class _MyElement extends HTMLXElement
{
    name = "World";
}

const MyElement = element("my-element", { template, style })(_MyElement);

document.body.appendChild(new MyElement());

You also can extends builtin elements using the extends option with the mixin HTMLXElement.as.

import HTMLXElement, { element } from "@surface/htmlx-element";

@element("my-element", { extends: "button" })
class MyElement extends HTMLXElement.as(HTMLButtonElement)
{
    /* ... */
}

document.body.appendChild(new MyElement());

Note that currently custom elements are registered at declaration time in the global scope due a limitation of the current web components spec. This is expected to change with the arrival of Custom Element Registry.

Templating

Templates are where the magic happens. It can handle some types of directives to present data in dynamic ways.

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.

Computed properties

Since readonly properties cannot be observed, to make them reactive it is necessary to map their dependencies using the @computed decorator.

import HTMLXElement, { computed, element } from "@surface/htmlx-element";

const template = "<span>Computed: {host.sum}</span>";

@element("my-element", { template })
class MyElement extends HTMLXElement
{
    private a: number = 0;
    private b: number = 0;

    // When **a** or **b** changes, the **sum** is notified.
    @computed("a", "b")
    public get sum(): number
    {
        return this.a + this.b;
    }
}

Scopes

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

The upper scope contains only the variable host which refers to the template owner (shadowroot host).

The base scope resembles something like this but it can also be extended using directives as we'll see later.

type Scope = { host: MyElement };

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-element, 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 HTMLXElement { element } from "@surface/htmlx-element";

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

@element("my-element", { template })
class MyComponent extends HTMLXElement
{
    public items: string[] = [];

    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 { painting }             from "@surface/htmlx";
import HTMLXElement { element } from "@surface/htmlx-element";

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

@element("my-element", { template })
class MyComponent extends HTMLXElement
{
    public items: string[] = [];

    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 }  from "@surface/htmlx";
import { Directive }              from "@surface/htmlx";
import HTMLXElement { Directive } from "@surface/htmlx-element";

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";
    }
}

// Registered at global scope
HTMLXElement.registerDirective("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>
`;

// Registered at my-element scope
@element("my-element", { template, directives: { 'el-show': ShowDirective } })
class MyElement extends HTMLXElement
{ /* ... */ }

Decorators

In addition, @surface/htmlx-element also provides a set of decorators that help with most trivial tasks.

attribute

Keeps sync between property and decorator.

import HTMLXElement { attribute, element } from "@surface/htmlx-element";

@element("my-element")
class MyComponent extends HTMLXElement
{
    @attribute
    public string: string = "some string";

    @attribute(Boolean)
    public boolean: boolean = false;

    @attribute({ type: Number })
    public number: number = 5;

    @attribute({ name: "json" type: JSON })
    public object: object = { foo: "bar" };

    @attribute({ type: { parse: x => x === "true" || x === "", stringify: String }, })
    public customParser: boolean = false;
}

Results

    <my-element string="some string" boolean number="5" json='{"foo":"bar"}' custom-parser="false"></my-element>

event

Listen for a host event using the decorated method as a handler.

import HTMLXElement { event } from "@surface/htmlx-element";

@element("my-element")
class MyComponent extends HTMLXElement
{
    @event("click")
    public onClick(event: Event): void
    {
        /* Do Something */
    }
}

listener

Listen to property changes.

import HTMLXElement { listener } from "@surface/htmlx-element";

@element("my-element")
class MyComponent extends HTMLXElement
{
    public value: number = 0;

    @listener("value")
    public valueListener(value: number): void
    {
        /* Do Something */
    }
}

query

Injects and optionally cache lazy queried element.

import HTMLXElement { query } from "@surface/htmlx-element";

const template = "<input type='text' /><button>Click Me</button>";

@element("my-element", { template })
class MyComponent extends HTMLXElement
{
    @query("input")
    public input: HTMLInputElement!;

    @query("button", true) // no cache
    public button: HTMLButtonElement!;
}

queryAll

Injects and optionally cache lazy queried an list of elements.

import HTMLXElement { queryAll } from "@surface/htmlx-element";

const template =
`
    <table>
        <tr>
            <td>
                <input type='text' />
                <button>Click Me</button>
            </td>
        </tr>
        <tr>
            <td>
                <input type='text' />
                <button>Click Me</button>
            </td>
        </tr>
        <tr>
            <td>
                <input type='text' />
                <button>Click Me</button>
            </td>
        </tr>
    </table>
`;

@element("my-element", { template })
class MyComponent extends HTMLXElement
{
    @queryAll("tr td input")
    public input: HTMLInputElement[]!;

    @queryAll("tr td button", true) // no cache
    public button: HTMLButtonElement[]!;
}

styles

Styles adopted by the shadow root. Particularly useful when used with inheritance or mixins.

import HTMLXElement { element, styles } from "@surface/htmlx-element";

@styles(".danger { color: red; }")
class StyleableElement extends HTMLXElement
{ }

@element("my-element", { template: "<span class='danger'>Some critical message</span>" })
class MyElement extends StyleableElement
{ }

Package Sidebar

Install

npm i @surface/htmlx-element

Weekly Downloads

1

Version

1.0.0-dev.202301061616

License

MIT

Unpacked Size

47.4 kB

Total Files

39

Last publish

Collaborators

  • hitalloexiled