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

1.0.0-dev.202109011451 • 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/custom-element 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.

Getting Started

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

Simple hello world component

Typescript.

import CustomElement, { element } from "@surface/custom-element";

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

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

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

Javascript (no decorators).

import CustomElement, { element } from "@surface/custom-element";

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

class _MyElement extends CustomElement
{
    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 CustomElement.as.

import CustomElement, { element } from "@surface/custom-element";

@element("my-element", { extends: "button" })
class MyElement extends CustomElement.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 suports 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.toogle = !host.toogle"></my-element>
<!--desugared to-->
<my-element @click="() => host.toogle = !host.toogle"></my-element>

Class and Style

class and style properties has a special binding handlers.

class bindind 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 bindind 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.
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 CustomElement, { computed, element } from "@surface/custom-element";

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

@element("my-element", { template })
class MyElement extends CustomElement
{
    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 model owner (shadowroot host). Inside the child elements, it is possible to access the variable this which refers to the element itself.

<div>{this.nodeName}<span name="{this.nodeName}">{this.nodeName}</span></div>
<!-- Results -->
<div>DIV<span name="SPAN">SPAN</span></div>

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

type Scope = { host: MyElement, this?: HTMLElement };

Template Directive Statements

Template Directive Statements 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.lenght > 0" #for="item of host.items">{item.name}</span>
<span #else>No data avaliable</span>
<!--decomposes-to-->
<template #if="host.items.lenght > 0">
    <template #for="item of host.items">
        <span>{item.name}</span>
    </template>
</template>
<template #else>
    <span>No data avaliable</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/custom-element, templates additionally provide the ability to inject the client's html 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 model 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" #placeholder-key="key"></span>
<span #inject="scope" #inject-key="key"></span>

Usefull to elaborate more complex scenarios.

<!--my-element-->
<table>
    <th #for="header of host.headers">
        <template #placeholder="{ header }" #placeholder-key="`header.${header}`">{header}</template>
    </th>
    <tr #for="item of host.items">
        <td #for="header of host.headers">
            <template #placeholder="{ 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>

Awaiting painting

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

import CustomElement { element } from "@surface/custom-element";

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

@element("my-element", { template })
class MyComponent extends CustomElement
{
    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 method.

import CustomElement { element, painting } from "@surface/custom-element";

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

@element("my-element", { template })
class MyComponent extends CustomElement
{
    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 CustomElement.registerDirective on global scope or element scope through @element decorator.

import type { DirectiveContext }   from "@surface/custom-element";
import CustomElement { Directive } from "@surface/custom-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
CustomElement.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 CustomElement
{ /* ... */ }

Custom Processing

If you need more control over you component but do not want to give up the reactivity it is also possible to manually process the template. Just remember of always dispose your unused stuffs.

import type { IDisposable }                  from "@surface/core";
import type { disposeTree, processTemplate } from "@surface/custom-element";

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

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

        const [content, disposable] = processTemplate("<span>Hello {name}!!!</span>", { name: "World" });

        this.shadowRoot!.appendChild(content);

        this.disposable = disposable;
    }

    public dispose(): void
    {

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

        // Disposes nested custom elements
        disposeTree(this.shadowRoot);
    }
}

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

Decorators

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

attribute

Keeps sync between property and decorator.

import CustomElement { attribute, element } from "@surface/custom-element";

@element("my-element")
class MyComponent extends CustomElement
{
    @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 === "", stringfy: 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 CustomElement { event } from "@surface/custom-element";

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

listener

Listen to property changes.

import CustomElement { listener } from "@surface/custom-element";

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

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

query

Injects and optionally cache lazy queried element.

import CustomElement { query } from "@surface/custom-element";

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

@element("my-element", { template })
class MyComponent extends CustomElement
{
    @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 CustomElement { queryAll } from "@surface/custom-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 CustomElement
{
    @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 CustomElement { element, styles } from "@surface/custom-element";

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

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

Package Sidebar

Install

npm i @surface/custom-element

Weekly Downloads

1

Version

1.0.0-dev.202109011451

License

MIT

Unpacked Size

216 kB

Total Files

152

Last publish

Collaborators

  • hitalloexiled