Using Web Components with dd<el>: Better Together

dd<el> pairs powerfully with Web Components to create reusable, encapsulated custom elements with all the benefits of dd<el>’s declarative DOM construction and reactivity system.

Why Combine dd<el> with Web Components?

  • Declarative DOM creation within your components
  • Reactive attribute updates through signals
  • Simplified event handling with the same events API
  • Clean component lifecycle management
// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-signals.js import { customElementRender, customElementWithDDE, } from "deka-dom-el"; /** @type {ddePublicElementTagNameMap} */ import { S } from "deka-dom-el/signals"; S.observedAttributes; // “internal” utils import { lifecyclesToEvents } from "deka-dom-el";

# Getting Started: Web Components Basics

Web Components are a set of standard browser APIs that let you create custom HTML elements with encapsulated functionality. They consist of three main technologies:

Let’s start with a basic Custom Element example without dd<el> to establish the foundation:

export class HTMLCustomElement extends HTMLElement{ static tagName= "custom-element"; // just suggestion, we can use `el(HTMLCustomElement.tagName)` static observedAttributes= [ "custom-attribute" ]; constructor(){ super(); // nice place to prepare custom element } connectedCallback(){ // nice place to render custom element } attributeChangedCallback(name, oldValue, newValue){ // listen to attribute changes (see `S.observedAttributes`) } disconnectedCallback(){ // nice place to clean up } // for example, we can mirror get/set prop to attribute get customAttribute(){ return this.getAttribute("custom-attribute"); } set customAttribute(value){ this.setAttribute("custom-attribute", value); } } customElements.define(HTMLCustomElement.tagName, HTMLCustomElement);

For complete information on Web Components, see the MDN documentation. Also, Handy Custom Elements Patterns provides useful techniques for connecting attributes with properties.

# dd<el> Integration: Step 1 - Event Handling

The first step in integrating dd<el> with Web Components is enabling dd<el>’s event system to work with your Custom Elements. This is done with customElementWithDDE, which makes your Custom Element compatible with dd<el>’s event handling. (Notice that customElementWithDDE is actually decorator)

customElementWithDDE

Purpose
Enables dd<el>’s event system to work with your Custom Element
Usage
customElementWithDDE(YourElementClass)
Benefits
Allows using on.connected(), on.disconnected() or S.observedAttributes().
import { customElementWithDDE, el, on } from "./esm-with-signals.js"; export class HTMLCustomElement extends HTMLElement{ static tagName= "custom-element"; connectedCallback(){ this.append( el("p", "Hello from custom element!") ); } } customElementWithDDE(HTMLCustomElement); customElements.define(HTMLCustomElement.tagName, HTMLCustomElement); const instance= el(HTMLCustomElement.tagName); on.connected( // preffered e=> console.log("Element connected to the DOM (v1):", e) )(instance); instance.addEventListener( "dde:connected", e=> console.log("Element connected to the DOM (v2):", e) ); document.body.append( instance, );

Key Point: The customElementWithDDE function adds event dispatching to your Custom Element lifecycle methods, making them work seamlessly with dd<el>’s event system.

# dd<el> Integration: Step 2 - Rendering Components

The next step is to use dd<el>’s component rendering within your Custom Element. This is done with customElementRender, which connects your dd<el> component function to the Custom Element.

customElementRender

Purpose
Connects a dd<el> component function to a Custom Element
Parameters
  1. Target (usually this or this.shadowRoot)
  2. Component function that returns a DOM tree
  3. Optional: Attributes transformer function (empty by default or S.observedAttributes)
Returns
The rendered DOM tree
import { customElementRender, customElementWithDDE, } from "./esm-with-signals.js"; export class HTMLCustomElement extends HTMLElement{ static tagName= "custom-element"; static observedAttributes= [ "attr" ]; connectedCallback(){ customElementRender( this.attachShadow({ mode: "open" }), ddeComponent, this ); } set attr(value){ this.setAttribute("attr", value); } get attr(){ return this.getAttribute("attr"); } } import { el, on, scope } from "./esm-with-signals.js"; function ddeComponent({ attr }){ scope.host( on.connected(e=> console.log(e.target.outerHTML)), ); return el().append( el("p", `Hello from Custom Element with attribute '${attr}'`) ); } customElementWithDDE(HTMLCustomElement); customElements.define(HTMLCustomElement.tagName, HTMLCustomElement); document.body.append( el(HTMLCustomElement.tagName, { attr: "Attribute" }) );

In this example, we’re using Shadow DOM (this.attachShadow()) for encapsulation, but you can also render directly to the element with customElementRender(this, ...).

# Reactive Web Components with Signals

One of the most powerful features of integrating dd<el> with Web Components is connecting HTML attributes to dd<el>’s reactive signals system. This creates truly reactive custom elements.

Two Ways to Handle Attributes:

  1. Using standard attribute access (this.getAttribute(<name>)) - Passes attributes as regular values (static)
  2. S.observedAttributes - Transforms attributes into signals (reactive)

Using the S.observedAttributes creates a reactive connection between your element’s attributes and its internal rendering. When attributes change, your component automatically updates!

import { customElementRender, customElementWithDDE, el, on, scope, } from "./esm-with-signals.js"; import { S } from "./esm-with-signals.js"; export class HTMLCustomElement extends HTMLElement{ static tagName= "custom-element"; static observedAttributes= [ "attr" ]; connectedCallback(){ customElementRender( this.attachShadow({ mode: "open" }), ddeComponent, S.observedAttributes ); } set attr(value){ this.setAttribute("attr", value); } get attr(){ return this.getAttribute("attr"); } } /** @param {{ attr: ddeSignal<string, {}> }} props */ function ddeComponent({ attr }){ scope.host( on.connected(e=> console.log(( /** @type {HTMLParagraphElement} */ (e.target)).outerHTML)), ); return el().append( el("p", S(()=> `Hello from Custom Element with attribute '${attr.get()}'`)) ); } customElementWithDDE(HTMLCustomElement); customElements.define(HTMLCustomElement.tagName, HTMLCustomElement); document.body.append( el(HTMLCustomElement.tagName, { attr: "Attribute" }) ); setTimeout( ()=> document.querySelector(HTMLCustomElement.tagName).setAttribute("attr", "New Value"), 3*750 );

How S.observedAttributes Works

  1. Takes each attribute listed in static observedAttributes
  2. Creates a dd<el> signal for each one
  3. Automatically updates these signals when attributes change
  4. Passes the signals to your component function
  5. In opposite, updates of signals trigger attribute changes
  6. Your component reacts to changes through signal subscriptions

# Working with Shadow DOM

Shadow DOM provides encapsulation for your component’s styles and markup. When using dd<el> with Shadow DOM, you get the best of both worlds: encapsulation plus declarative DOM creation.

Shadow DOM Encapsulation

<my-custom-element>
	┌─────────────────────────┐
		#shadow-root

	Created with dd<el>
		┌──────────────────┐
			<div>
				<h2>Title</h2>
				<p>Content</p>
import { el, customElementRender, customElementWithDDE, } from "./esm-with-signals.js"; function ddeComponent(){ return el().append( el("style", ` .red{ color: firebrick; } `), el("p", { className: "red" }).append( "Hello from ", el("slot", "Custom Element"), "!" ) ); } export class A extends HTMLElement{ static tagName= "custom-element-without"; connectedCallback(){ customElementRender(this, ddeComponent); } } customElementWithDDE(A); customElements.define(A.tagName, A); export class B extends HTMLElement{ static tagName= "custom-element-open"; connectedCallback(){ customElementRender( this.attachShadow({ mode: "open" }), ddeComponent ); } } customElementWithDDE(B); customElements.define(B.tagName, B); export class C extends HTMLElement{ static tagName= "custom-element-closed"; connectedCallback(){ customElementRender( this.attachShadow({ mode: "closed" }), ddeComponent ); } } customElementWithDDE(C); customElements.define(C.tagName, C); document.body.append( el(A.tagName).append("Without shadowRoot"), el("hr"), el(B.tagName).append("Open shadowRoot"), el("hr"), el(C.tagName).append("Closed shadowRoot"), el("style", ` .red{ color: crimson; } `), ); console.log(A.tagName, "expect modifications"); document.body.querySelector(A.tagName).querySelector("p").textContent+= " (editable with JS)"; console.log(B.tagName, "expect modifications"); document.body.querySelector(B.tagName).shadowRoot.querySelector("p").textContent+= " (editable with JS)"; console.log(C.tagName, "expect error ↓"); document.body.querySelector(C.tagName).querySelector("p").textContent+= " (editable with JS)";

For more information on Shadow DOM, see Using Shadow DOM, or the comprehensive Shadow DOM in Depth.

# Working with Slots

Besides the encapsulation, the Shadow DOM allows for using the <slot>element(s). You can simulate this feature using simulateSlots:

import { customElementRender, customElementWithDDE, el, simulateSlots } from "./esm-with-signals.js"; export class HTMLCustomElement extends HTMLElement{ static tagName= "custom-slotting"; connectedCallback(){ const c= ()=> simulateSlots(this, ddeComponent()); customElementRender(this, c); } } customElementWithDDE(HTMLCustomElement); customElements.define(HTMLCustomElement.tagName, HTMLCustomElement); document.body.append( el(HTMLCustomElement.tagName), el(HTMLCustomElement.tagName).append( "Slot" ), el(ddeComponentSlot), el(ddeComponentSlot).append( "Slot" ), ); function ddeComponent(){ return el().append( el("p").append( "Hello ", el("slot", "World") ) ); } function ddeComponentSlot(){ return simulateSlots(el().append( el("p").append( "Hello ", el("slot", "World") ) )); }

simulateSlots

Purpose
Provides slot functionality when you cannot/do not want to use shadow DOM
Parameters
A mapping object of slot names to DOM elements

# Best Practices for Web Components with dd<el>

When combining dd<el> with Web Components, follow these recommendations:

  1. Always use customElementWithDDE to enable event integration
  2. Prefer S.observedAttributes for reactive attribute connections
  3. Create reusable component functions that your custom elements render
  4. Use scope.host() to clean up event listeners and subscriptions
  5. Add setters and getters for better property access to your element

Common Issues

Events not firing properly
Make sure you called customElementWithDDE before defining the element
Attributes not updating
Check that you’ve properly listed them in static observedAttributes
Component not rendering
Verify customElementRender is called in connectedCallback, not constructor

# Mnemonic

  • customElementRender(<connect-target>, <render-function>[, <properties>]) — use function to render DOM structure for given custom element (or its Shadow DOM)
  • customElementWithDDE(<custom-element>) — register <custom-element> to DDE library, see also `lifecyclesToEvents`, can be also used as decorator
  • S.observedAttributes(<custom-element>) — returns record of observed attributes (keys uses camelCase and values are signals)
  • lifecyclesToEvents(<class-declaration>) — convert lifecycle methods to events, can be also used as decorator
  • simulateSlots(<class-instance>, <body>) — simulate slots for Custom Elements without shadow DOM
  • simulateSlots(<dde-component>[, <mapper>]) — simulate slots for “dde”/functional components