webcomp-ts

Base class for web components has basic boilerplate code to simplify custom element implementation

Usage no npm install needed!

<script type="module">
  import webcompTs from 'https://cdn.skypack.dev/webcomp-ts';
</script>

README

WebComp - TypeScript base class for web components

WebComp is a minimalistic base class to build your own custom web components in TypeScript or JavaScript. Base class does not build any opinionated abstraction on top of standards. The ambition is to have in new component only necessary component logic and inherit helper and boilerplate code from this base class.

Features

  • Simple definition of component's HTML and CSS
  • Access to elements marked with elem attribute in HTML markup
  • Registering custom element with tag name derived from class name
  • Cached HTML templates for all derived web components
  • Dispatch of custom events
  • Setting of element attributes without value (boolean)
  • Callback for animation (~ with 60 calls per second)

Quick start

npm install webcomp-ts ... and create your component derived from WebComp base class:

import { WebComp } from "../web-comp.js";

export class FooBar extends WebComp {
    constructor() {
        super();
    }

    get html() { return `
<div class="foo">
    <p>This is a custom web component.</p>
</div>
`;
    }

    get css() { return `
.foo {
    background-color: lightyellow;
}
`; }
}

The custom web component can be used in HTML:

<foo-bar></foo-bar>

<script type="module">
    import { FooBar } from "./foo-bar.js";
    FooBar.defineElement();
</script>

Custom components with WebComp base class

The set of W3C Web component standards eliminates need to use any opinionated web framework in development of rich single page web applications. The W3C web component standards are implemented in all major web browsers. Web components work well also on legacy browsers with polyfill.

WebComp base class provides helper functions and boilerplate code that minimizes the code in your custom component. This simplifies and speeds up development of standards-based custom web components: inplementation will contain only relevant code, no "plumbing" and "hacking" code.

Definition

Custom component extends the base class WebComp

export class MyFooBar extends WebComp {
    ...
}

Custom component's class name must consist form more words. Component's HTML tag name is determined from the class name: class MyFooBar will be registered in browser as my-foo-bar tag. The tag is used in HTML as <my-foo-bar></my-foo-bar>.

Custom component has parameters name with class name, and tag with the tag name. These fields are set by base class.

New custom component is "started" with static method MyFooBar.defineElement() also inherited from the base class.

Rendering

Custom element overwrites getter for HTML template and getter for CSS:

...
get html(): string { return `
    <div class="content">
        <slot></slot>
        <p elem="task"></p>
    </div>
    <hr />
    <button elem="delete">Delete</button>
`; }

get css(): string { return `
    .content {
        background-color: silver;
    }
`; }

HTML is a template with DOM fragment:

  • Fragment may have more elements in root level they do not require wrapping element.

    <div>Foo Bar</div>
    <hr />
    <button elem="foo-bar">Foo Bar Action</button>
    
  • Marking of elements with elem attribute gives a key for a reference to the HTML element object. Object can be accessed by method dom:

    const task = this.dom("task");
    task.innerText = "Lorem ipsum dolor sit amet.";`
    
  • Template may contain placeholder for inner content of the custom element in HTML - slot. For example our component can receive in HTML some text content, that will be placed into the slot of the component:

    <my-foo-bar>
        <h1>Deep Dive into Magic</h1>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
    </my-foo-bar>
    

    DOM elements in the component's HTML (h1 and p) will be placed into the default slot. Advanced scenarios may use named slots with more structure data (see Mozilla article for more inspiration).

Animation

Dynamic changes in time can be implemented by providing implementation of animation() method. This method runs ~ 60-times per second. Return value decides, if the animation will continue (= true) or stop (= false). If you want to start a stopped animation, call the runAnimation() method and in the callback method animation() return true to keep the animation loop running. See todo-clock.ts for inspiration.

protected animation(): boolean {
    // updates ~60 times a second - will be eye-catching but energy-hungry :-)
    // less-ambitious version is in example code
    const clock = this.dom("clock") as HTMLSpanElement;
    clock.innerHTML = `${(new Date()).toISOString().replace(/[TZ]/g, " ")} GMT`;
    return true;
}

For actions that do not need so high frequency, setTimeout() and setInterval() could be a better option. In both cases we must save the timer ID, and in the overwritten method disconnectedCallback() clear the timer - call the clearTimeout() or clearInterval().

private tick: number;
private time: string;

constructor() {
    super();
    this.tick = setInterval(() => this.time = (new Date()).toISOString(), 1000);
}

protected disconnectedCallback(): void {
    clearInterval(this.tick);
}

State: HTML element attributes and object parameters

With HTML DOM elements on one side and TypeScript / JavaScript objects on other side there is a dual appearence of the same component. To keep the mental model simple and intuitive, it is practical to keep the public state of the element in DOM attributes. The state is stored in elementar types (string, number, boolean). The component state can be read and written both through DOM API and also through object parameter.

We suggest to use Attribute getter-setter pattern:

In custom component we keep both "worlds" bound by getter and setter for each attribute, value is stored in DOM element attribute:

get task(): string {
    return this.getAttribute("task") as string;
}
set task(value: string) {
    this.setAttribute("task", value);
}

For Boolean values it is practical to use element attributes without values. Base class offers utility method setWithoutValue that keeps also setters for booleans simple single-liners:

get done(): boolean {
    return this.hasAttribute("done");
}
set done(value: boolean) {
    this.setWithoutValue(this, "done", value);
}

Component can react to changes of parameters / attributes by listener defined in W3C Custom Elemens standard. The list of attributes watched by the listener is defined by overwritten method observedAttributes that returns array of attribute names. Listener is overwritten method attributeChangedCallback.

static get observedAttributes(): string[] {
    return ["task", "done"];
}
protected attributeChangedCallback(name: string, oldval: string, newval: string) {
    switch (name) {
        case "task":
            const task = this.dom("task");
            if (task) {
                task.innerText = newval || "none";
            }
            break;
        case "done":
            const value = newval !== null;
            this.setWithoutValue("task", "done", value);
            this.setWithoutValue("done", "checked", value);
            break;
        default:
            break;
    }
}

Events

Component handles events from its elements. Simplest way to setup the event handlers is by setting event listeners in constructor of the custom component:

const done = this.dom("done") as HTMLInputElement;
done.addEventListener("change", () => (this.done = done.checked));

Component communicates with its environment (other elements / JavaScript objects) with standard DOM mechanism - by dispatching standard or custom events. Dispatching means emiting messages that bubble up the DOM tree, until the event is processed. In mini-example app the task element dispatches custom event that is processed by parent element task-list.

We may add event handlers or event listeners also outside of the direct DOM hierarchy may register for listening events by method addEventListener:

todoTask.addEventListener("done", (e) => {
    console.debug(e);
    ...
});

WebComp base class simplifies dispatching of custom events by method dispatchEvent:

this.dispatch("delete-task", this);

Cleanup

If the component uses resources that has to be released by end-of-life (to avoid memory leaks, to stop running timers, releasing listeners, etc), all the cleanup tasks can be executed in the disconnectedCallback() method.

private tick: number;
private time: string;

constructor() {
    super();
    this.tick = setInterval(() => this.time = (new Date()).toISOString(), 1000);
}

protected disconnectedCallback(): void {
    clearInterval(this.tick);
}

Side Notes

For development I like to use ES6 modules, but TypeScript struggles to convert "non-relative" imports to ES6 module imports. I use relative inport from node_modules, and add also .js extension:

import { WebComp } from "../node_modules/webcomp-ts/dist/web-comp.js";

... and it should be

import { WebComp } from "webcomp-ts";

For production build I use Rollup, that bundles everything into single JavaScript file - and everything automagically works.

If you would find the right configuration of tsconfog.json that allows the module import without dirty hacks, please let me know. THANKS in advance.

Credits

This component aggregates ideas from