Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ellement

Ellement is a small TypeScript-first helper for building native Web Components with a lightweight component class, tagged html / css templates, local state, shadow DOM support, and delegated events.

The project is currently a Vite + TypeScript workspace and is not published to npm yet, so this README focuses on how the library works and how to use the source directly while it is in development.

What Ellement Provides

  • A base EllementComponent class that extends HTMLElement.
  • Shadow DOM rendering by default.
  • A state() helper that stores local component state and schedules re-renders.
  • Tagged html and css template helpers.
  • Static component styles plus per-render reactive styles.
  • Delegated event handling through this.on(...).
  • A simple render contract: return an HTML template or an object containing html and optional styles.

Project Structure

src/
  core/
    EllementComponent.ts     Base class for all Ellement components
  template/
    css.ts                   css tagged template helper
    html.ts                  html tagged template helper and parser
    index.ts                 Template helper exports
    types.ts                 TemplateResult types
  examples/
    Counter.element.ts       Example counter Web Component
  main.ts                    Demo entry that registers the counter example
  style.css                  Vite demo styles
index.html                   Demo page

Quick Example

import EllementComponent from "./src/core/EllementComponent";
import { html, css } from "./src/template";

class GreetingElement extends EllementComponent {
  static styles = css`
    :host {
      display: block;
      font-family: system-ui, sans-serif;
    }

    strong {
      color: royalblue;
    }
  `;

  render() {
    return html`
      <p>Hello from <strong>Ellement</strong>.</p>
    `;
  }
}

customElements.define("greeting-element", GreetingElement);

Then use the custom element in HTML:

<greeting-element></greeting-element>

Creating Components

Every Ellement component extends EllementComponent and implements render().

import EllementComponent from "./src/core/EllementComponent";
import { html } from "./src/template";

class MessageElement extends EllementComponent {
  render() {
    return html`
      <article>
        <h2>Message</h2>
        <p>This content is rendered inside the component root.</p>
      </article>
    `;
  }
}

customElements.define("message-element", MessageElement);

By default, Ellement attaches an open shadow root and renders into it. This keeps component markup and styles scoped away from the main document.

Rendering HTML

Use the html tagged template helper to create a TemplateResult.

render() {
  const name = "Ada";

  return html`
    <p>Hello, ${name}.</p>
  `;
}

Interpolated values are normalized before being added to the output string:

  • null, undefined, and false render as an empty string.
  • Arrays are recursively joined.
  • All other values are converted with String(value).

Example:

render() {
  const items = ["One", "Two", "Three"];

  return html`
    <ul>
      ${items.map((item) => `<li>${item}</li>`)}
    </ul>
  `;
}

Styling Components

Ellement supports two kinds of styles:

  • Static styles through static styles.
  • Reactive styles returned from render().

Static Styles

Static styles are defined once on the component class.

class BadgeElement extends EllementComponent {
  static styles = css`
    :host {
      display: inline-block;
      padding: 4px 8px;
      border-radius: 999px;
      background: #222;
      color: white;
    }
  `;

  render() {
    return html`<slot></slot>`;
  }
}

Reactive Styles

If styles depend on component state, return an object with both html and styles.

class ToggleBadge extends EllementComponent {
  active = this.state(false);

  render() {
    return {
      styles: css`
        :host {
          background: ${this.active.value ? "seagreen" : "gray"};
          color: white;
        }
      `,
      html: html`
        <button>${this.active.value ? "Active" : "Inactive"}</button>
      `,
    };
  }
}

During rendering, Ellement combines static styles first and reactive styles second, then injects them into a single <style> tag before the component HTML.

State

Use this.state(initialValue) to create local component state.

class CounterElement extends EllementComponent {
  count = this.state(0);

  render() {
    return html`
      <button id="dec">-</button>
      <span>${this.count.value}</span>
      <button id="inc">+</button>
    `;
  }

  events() {
    this.on("click", "#inc", () => {
      this.count.setState((count) => count + 1);
    });

    this.on("click", "#dec", () => {
      this.count.setState((count) => count - 1);
    });
  }
}

The object returned by state() has:

Property Description
value Getter for the current state value.
setState(next) Updates state and requests a render. Accepts a direct value or updater function.

setState() uses Object.is() to compare the old and new values. If the value did not change, Ellement skips the render.

For object state, return a new object when updating:

this.profile.setState((profile) => ({
  ...profile,
  name: "Grace",
}));

Render Scheduling

Calling setState() does not render synchronously. It calls requestRender(), which batches rendering into a microtask with queueMicrotask().

That means multiple state updates in the same synchronous call stack are coalesced into a single render request.

this.count.setState((count) => count + 1);
this.count.setState((count) => count + 1);

Both updates happen before the scheduled render runs.

Events

Use the optional events() lifecycle method to register delegated event handlers.

events() {
  this.on("click", "button.save", (event, button) => {
    console.log("Save clicked", event, button);
  });
}

this.on(event, selector, handler) listens from the component root and walks the event's composed path until it finds an element matching the selector.

The handler receives:

Argument Description
event The typed DOM event object.
el The matching HTMLElement or EllementComponent.

Events are initialized once after the first render. Because handlers are delegated from the root, they continue to work after later renders replace the component's HTML.

Shadow DOM

Ellement uses shadow DOM by default:

protected static useShadow = true;

To render directly into the custom element instead, override useShadow:

class LightDomElement extends EllementComponent {
  static useShadow = false;

  render() {
    return html`<p>This renders in light DOM.</p>`;
  }
}

Use light DOM when you want global styles, normal document selectors, or external layout systems to affect the component directly.

Full Counter Example

The repository includes src/examples/Counter.element.ts, which demonstrates local object state, static styles, reactive styles, and events.

import { css, html } from "../template";
import EllementComponent from "../core/EllementComponent";

export default class CounterElement extends EllementComponent {
  counterState = this.state({
    count: 0,
    color: "black",
  });

  static styles = css`
    :host {
      display: inline-block;
      padding: 16px;
      border-radius: 8px;
      font-family: sans-serif;
      border: 1px solid #ccc;
      transition:
        background 0.2s,
        color 0.2s;
    }

    button {
      margin: 4px;
      padding: 6px 10px;
      border: 1px solid #888;
      border-radius: 4px;
      cursor: pointer;
      background: #f5f5f5;
    }

    span {
      margin: 0 8px;
      font-weight: bold;
      font-size: 18px;
    }
  `;

  render() {
    return {
      styles: css`
        :host {
          background: ${this.counterState.value.color === "black"
            ? "black"
            : "white"};
          color: ${this.counterState.value.color === "black"
            ? "white"
            : "black"};
        }
      `,
      html: html`
        <button id="color">
          Change color to
          ${this.counterState.value.color === "black" ? "white" : "black"}
        </button>
        <button id="dec">-</button>
        <span>${this.counterState.value.count}</span>
        <button id="inc">+</button>
      `,
    };
  }

  events() {
    this.on("click", "#color", () => {
      this.counterState.setState((value) => ({
        ...value,
        color: value.color === "black" ? "white" : "black",
      }));
    });

    this.on("click", "#inc", () => {
      this.counterState.setState((value) => ({
        ...value,
        count: value.count + 1,
      }));
    });

    this.on("click", "#dec", () => {
      this.counterState.setState((value) => ({
        ...value,
        count: value.count - 1,
      }));
    });
  }
}

customElements.define("counter-element", CounterElement);

API Reference

EllementComponent

Base class for all components.

abstract class EllementComponent extends HTMLElement {
  protected root: HTMLElement | ShadowRoot;
  static useShadow = true;
  static styles?: TemplateResult;
  static props?: Record<string, EllementProp>;

  connectedCallback(): void;
  requestRender(): void;
  state<T>(initial: T): {
    readonly value: T;
    setState(next: T | ((prev: T) => T)): void;
  };
  protected on<K extends keyof HTMLElementEventMap>(
    event: K,
    selector: string,
    handler: (
      e: HTMLElementEventMap[K],
      el: HTMLElement | EllementComponent,
    ) => void,
  ): void;
  protected events(): void;
  abstract render(): TemplateResult | { styles?: TemplateResult; html: TemplateResult };
}

html(strings, ...values)

Creates an HTML TemplateResult.

const view = html`<p>${message}</p>`;

css(strings, ...values)

Creates a CSS TemplateResult.

const styles = css`
  :host {
    display: block;
  }
`;

htmlParser(html, staticCss?, reactiveCss?)

Converts Ellement template results into the final string inserted into the component root.

Most users do not need to call this directly. It is used internally by EllementComponent.

TemplateResult

type TemplateType = "html" | "css";

interface TemplateResult {
  strings: TemplateStringsArray;
  values: unknown[];
  type: TemplateType;
}

Current Limitations

Ellement is intentionally small and still early. Keep these behaviors in mind:

  • Rendering replaces root.innerHTML on every render. DOM nodes inside the component are recreated.
  • There is no DOM diffing or fine-grained update system yet.
  • Template values are stringified. They are not automatically escaped or sanitized.
  • Nested html or css template results are not parsed inside interpolations yet.
  • Event binding is delegated and initialized once after the first render.
  • static props exists in the type surface, but attribute/property reflection is not implemented yet.
  • There is no public package entrypoint yet. Current examples import from local source files.

Development Scripts

The workspace includes these scripts:

{
  "dev": "vite",
  "build": "tsc && vite build",
  "preview": "vite preview"
}

Use dev to run the local Vite demo, build to type-check and build, and preview to preview the built output.

Browser Support

Ellement depends on standard browser APIs:

  • Custom Elements
  • Shadow DOM
  • Template literal tags
  • queueMicrotask()
  • DOM events and composedPath()

It is designed for modern browsers that support native Web Components.

About

A tiny library for building reactive Custom Elements.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages